Skip to content

Add SLH-DSA Support - #455

Open
aidangarske wants to merge 20 commits into
wolfSSL:masterfrom
aidangarske:add-slhdsa
Open

Add SLH-DSA Support#455
aidangarske wants to merge 20 commits into
wolfSSL:masterfrom
aidangarske:add-slhdsa

Conversation

@aidangarske

@aidangarske aidangarske commented Jul 30, 2026

Copy link
Copy Markdown
Member

Description

Copilot AI review requested due to automatic review settings July 30, 2026 18:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@aidangarske
aidangarske force-pushed the add-slhdsa branch 2 times, most recently from 0487400 to 02cbe8b Compare July 30, 2026 18:27
@aidangarske aidangarske added the ci:nginx-pqc PR OSP toggle: run nginx-pqc label Jul 30, 2026
@aidangarske aidangarske added ci:libacvp-pqc PR OSP toggle: run libacvp-pqc and removed ci:nginx-pqc PR OSP toggle: run nginx-pqc ci:libacvp-pqc PR OSP toggle: run libacvp-pqc labels Jul 30, 2026
@aidangarske aidangarske added ci:libacvp-pqc PR OSP toggle: run libacvp-pqc and removed ci:libacvp-pqc PR OSP toggle: run libacvp-pqc labels Jul 30, 2026
@aidangarske
aidangarske marked this pull request as ready for review July 30, 2026 23:26
@aidangarske
aidangarske force-pushed the add-slhdsa branch 2 times, most recently from 8bf2ba2 to ba88c76 Compare July 31, 2026 01:05
@aidangarske aidangarske added ci:libacvp-pqc PR OSP toggle: run libacvp-pqc and removed ci:libacvp-pqc PR OSP toggle: run libacvp-pqc labels Jul 31, 2026
Frauschi

This comment was marked as resolved.

@aidangarske aidangarske added ci:libacvp-pqc PR OSP toggle: run libacvp-pqc and removed ci:libacvp-pqc PR OSP toggle: run libacvp-pqc labels Jul 31, 2026
@aidangarske
aidangarske requested a review from Frauschi July 31, 2026 19:38
@aidangarske aidangarske removed the ci:libacvp-pqc PR OSP toggle: run libacvp-pqc label Aug 1, 2026
@aidangarske aidangarske added ci:libacvp-pqc PR OSP toggle: run libacvp-pqc and removed ci:libacvp-pqc PR OSP toggle: run libacvp-pqc labels Aug 5, 2026
@aidangarske aidangarske added ci:libacvp-pqc PR OSP toggle: run libacvp-pqc and removed ci:libacvp-pqc PR OSP toggle: run libacvp-pqc labels Aug 5, 2026
@aidangarske
aidangarske requested a review from Frauschi August 5, 2026 21:01

@Frauschi Frauschi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐺 Skoll Code Review

Overall recommendation: REQUEST_CHANGES
Findings: 12 total — 5 posted, 7 skipped

Posted findings

  • [High] openssl_example.c rewrite hard-fails on the IDE/Android and IDE/XCODE flowsexamples/openssl_example.c:47-75 (duplicated in examples/pqc_openssl_example.c:225-238)
  • [Medium] KAT vector accounting: the verify-only SLH-DSA KAT can pass with zero vectors, the exact 3099 count is pinned against a floating OpenSSL tag, and the old 2602 comment was left behind.github/workflows/wolfssl-versions-pqc.yml:204-214
  • [Medium] WOLFSSL_SLHDSA_CONFIG is missing from the dependency cache key, undocumented in --help, and contradicted by INTEGRATION_GUIDEscripts/utils-wolfssl.sh:64-70
  • [Medium] Example build gating and portability: executableDir may trip -Wmaybe-uninitialized, and BUILD_PQC_EXAMPLE is true for verify-only SLH-DSA builds where the example always failsexamples/openssl_example.c:62-75; examples/pqc_openssl_example.c:225-238
  • [Low] wp_mlkem_kem.c doc comment no longer matches the code it documentssrc/wp_mlkem_kem.c:186
Skipped findings
  • [High] libacvp PQC job likely cannot build runtest, so the whole workflow may no-op
  • [Medium] SLH-DSA test coverage gaps: four guard paths and the deterministic-keygen seed length validation are unexercised
  • [Low] CI workflow hygiene: example steps run before the dependency push, nginx-pqc adds per-workflow apt installs, and test.sh defaults WOLFPROV_ROOT to a path nothing produces
  • [Low] wp_slhdsa_key_types uses opaque index arithmetic into a flat parameter array
  • [Low] test_slhdsa.c hygiene: randomised input described as deterministic, a verify context initialised with sign-only params, mixed XMEM*/libc string calls, and redundant double cleanup
  • [Low] SLH-DSA locking contract is looser than the mutex implies: the key mutex is held across the full multi-second sign and across thread-local CheckKey work, wp_slhdsa_get_key() hands out a raw pointer to guarded state, and construction-time hasPub/hasPriv writes are unlocked
  • [Info] settings.h pulls a wolfSSL algorithm header into every translation unit

Review generated by Skoll via Claude/Codex

Comment thread examples/openssl_example.c
Comment thread .github/workflows/wolfssl-versions-pqc.yml
Comment thread scripts/utils-wolfssl.sh
Comment thread src/wp_mlkem_kem.c Outdated

@Frauschi Frauschi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐺 Skoll Code Review

Overall recommendation: APPROVE
Findings: 16 total — 7 posted, 9 skipped

Posted findings

  • [Medium] SLH-DSA verify-only CI row is indistinguishable from the plain slhdsa row and cannot detect a lost WOLFSSL_SLHDSA_CONFIG.github/workflows/wolfssl-versions-pqc.yml:183-191
  • [Medium] wc_SlhDsaKey_CheckKey runs twice on every SLH-DSA keypair importsrc/wp_slhdsa_kmgmt.c:854,895
  • [Low] wp_slhdsa_validate holds keyMutex across a full key copy and wc_SlhDsaKey_CheckKeysrc/wp_slhdsa_kmgmt.c:745-795
  • [Low] nginx-pqc installs packages that are already baked into the test-deps image, against that image's stated policy.github/workflows/nginx-pqc.yml:132-136
  • [Low] Missing cast on OPENSSL_malloc returnsrc/wp_slhdsa_kmgmt.c:766
  • [Low] if (dupPub) is not guarded by ok while the adjacent if (ok && dupPriv) issrc/wp_slhdsa_kmgmt.c:455
  • [Low] word32 sigSz; declared uninitialized in wp_slhdsa_signsrc/wp_slhdsa_sig.c:344
Skipped findings
  • [Low] run_vector_family silently mutates its caller's local counters via bash dynamic scoping
  • [Low] 7 of 12 SLH-DSA AlgorithmIdentifier OID arcs are never exercised by any test
  • [Low] extra_key gains a stray trailing dash on 7 of 8 matrix rows
  • [Low] wp_slhdsa_validate nests refMutex inside keyMutex and runs the full CheckKey inside the shared key's critical section
  • [Low] wp_slhdsa_sign and wp_slhdsa_verify hold the per-key mutex across the entire multi-second wolfSSL SLH-DSA operation
  • [Info] Do not 'fix' SLH-DSA's export_object / verify_message_final to match ML-DSA and ECX -- SLH-DSA is the correct one
  • [Info] Examples gate on the configure macro WOLFPROV_HAVE_* rather than the resolved WP_HAVE_*
  • [Info] encData cleansed with post-encryption length instead of allocation size
  • [Info] Three sites write hasPub/hasPriv without keyMutex, contradicting the invariant documented at wp_slhdsa_kmgmt.c:271

Review generated by Skoll via Claude/Codex

printf '%s\n' "$tests" | grep -q 'test_mldsa_sign_verify' && dsa=1
echo "expect=${{ matrix.expect }} mlkem=$kem mldsa=$dsa"
printf '%s\n' "$tests" | \
grep -qE 'test_slhdsa_(sign_verify|public_keymgmt)' && slh=1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Medium] SLH-DSA verify-only CI row is indistinguishable from the plain slhdsa row and cannot detect a lost WOLFSSL_SLHDSA_CONFIG
💡 SUGGEST test

The PR adds a matrix row {"name":"master (SLH-DSA verify-only)", "enable":"--enable-slhdsa", "slh-config":"yes,sha2,verify-only", "expect":"slhdsa-verify"} whose whole purpose is to prove the WP_HAVE_SLHDSA_PRIVATE-off code path builds and works. No step actually asserts the build is verify-only. Three independent reasons, all verified: (1) the presence check greps test_slhdsa_(sign_verify|public_keymgmt) and test/unit.c:598 registers test_slhdsa_public_keymgmt outside the WP_HAVE_SLHDSA_PRIVATE guard, so the alternation matches in both builds and slh=1 either way; (2) the slhdsa and slhdsa-verify case arms are byte-identical ([ "$kem" = 0 ] && [ "$dsa" = 0 ] && [ "$slh" = 1 ]); (3) the new "Run SLH-DSA verify-only KAT" step runs evppkey_slh_dsa_sigver.txt, which passes on a full build too. If WOLFSSL_SLHDSA_CONFIG ever stops propagating through build-wolfprovider.sh -> utils-wolfssl.sh, this row silently degrades into a duplicate of the plain --enable-slhdsa row and stays green, leaving the entire verify-only path untested with no signal. That path is a substantial amount of new conditional code -- the omitted GEN_/SIGN_ dispatch entries, the #else branches in wp_slhdsa_dup/wp_slhdsa_match/wp_slhdsa_import/wp_slhdsa_export, and the whole IMPLEMENT_SLHDSA_PRIVATE_ENCODERS block.

Suggestion: Track private ops separately so the two rows assert different things:

      slhpriv=0
      printf '%s\n' "$tests" | grep -q 'test_slhdsa_sign_verify' && slhpriv=1

...
slhdsa) [ "$kem" = 0 ] && [ "$dsa" = 0 ] && [ "$slh" = 1 ] && [ "$slhpriv" = 1 ] ;;
slhdsa-verify) [ "$kem" = 0 ] && [ "$dsa" = 0 ] && [ "$slh" = 1 ] && [ "$slhpriv" = 0 ] ;;

Or assert on the build directly:
grep -q WOLFSSL_SLHDSA_VERIFY_ONLY wolfssl-install/include/wolfssl/options.h

Recommendation: Make the two rows assert different observable states, so a regression in the verify-only build configuration fails CI instead of silently collapsing the row into a duplicate.

Comment thread src/wp_slhdsa_kmgmt.c
if (rc != 0) {
ok = 0;
}
if (ok && (wc_SlhDsaKey_CheckKey(&slhdsa->key) != 0)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Medium] wc_SlhDsaKey_CheckKey runs twice on every SLH-DSA keypair import
💡 SUGGEST bug

For an EVP_PKEY_fromdata(..., EVP_PKEY_KEYPAIR, ...) import carrying both OSSL_PKEY_PARAM_PRIV_KEY and OSSL_PKEY_PARAM_PUB_KEY, wc_SlhDsaKey_CheckKey() is invoked twice on the same key: once immediately after wc_SlhDsaKey_ImportPrivate (line 854), and again in the "public import must remain consistent" block (line 895), because pubData != NULL and WC_SLHDSA_FLAG_PRIVATE is set by then. wc_SlhDsaKey_CheckKey re-derives PK.root from SK.seed, the single most expensive operation on an SLH-DSA key -- for the s parameter sets a full top-layer hypertree computation. The second call strictly subsumes the first (it validates the private against the actually-supplied public), so the first is duplicated work whenever a public component is also being imported. test_slhdsa_import_export_roundtrip exercises exactly this path for every entry in slhdsa_sets[], including SLH-DSA-SHA2-128s, doubling the cost of an already-slow test.

Suggestion: Skip the first check when a public key will also be imported, preserving it for the private-only case that test_slhdsa_validate_keypair relies on:

        if (ok && ((selection & OSSL_KEYMGMT_SELECT_PUBLIC_KEY) == 0) &&
                (wc_SlhDsaKey_CheckKey(&slhdsa->key) != 0)) {
            ok = 0;
        }

Recommendation: Gate the post-ImportPrivate CheckKey on the public key not being part of this import, so a keypair import pays for one root recomputation instead of two.

Comment thread src/wp_slhdsa_kmgmt.c

ok = wolfssl_prov_is_running() && (slhdsa != NULL) &&
((selection & WP_SLHDSA_POSSIBLE_SELECTIONS) != 0);
if (ok && (wp_lock(wp_slhdsa_get_mutex((wp_SlhDsa*)slhdsa)) != 1)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] wp_slhdsa_validate holds keyMutex across a full key copy and wc_SlhDsaKey_CheckKey
💡 SUGGEST bug

The full-check path takes the source key's keyMutex at line 745 and releases it only at 794. In between it allocates a second wp_SlhDsa via wp_slhdsa_new (which runs wc_SlhDsaKey_Init and initialises two mutexes), exports the private key to a heap buffer, re-imports it into the copy, runs wc_SlhDsaKey_CheckKey on the copy, and frees the copy. This is not a deadlock -- mutexes are per-object and wp_slhdsa_free(copy) only takes copy->refMutex -- but the lock is held across the most expensive FIPS 205 operation there is, so every concurrent sign, verify, or get_params on the same EVP_PKEY blocks for the full duration of an EVP_PKEY_check(). That matters specifically because this PR introduced the mutex to "serialize operations that share mutable wolfSSL key state"; nothing after the export touches shared state on the source key, so the lock does not need to cover the expensive part.

Suggestion: Snapshot the private key under the lock, release it, then do new/ImportPrivate/CheckKey/free on the copy outside:

/* export priv under keyMutex */
wp_unlock(wp_slhdsa_get_mutex((wp_SlhDsa*)slhdsa));
locked = 0;
/* copy + CheckKey here, lock-free */

Recommendation: Move the copy/import/CheckKey sequence outside the critical section -- only the ExportPrivate needs the lock. If the copy exists because wc_SlhDsaKey_CheckKey mutates the key, say so in the comment, since every other call site in the file (lines 854, 895, 1833) calls it on the live key.

# Build the oqs-demos nginx image with wolfProvider in place of
# oqs-provider. nginx is pinned to release-1.28.0 in the Dockerfile.
- name: Build quantum-safe nginx image
- name: Install nginx build dependencies

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] nginx-pqc installs packages that are already baked into the test-deps image, against that image's stated policy
💡 SUGGEST convention

The Docker-to-container migration added a step running apt-get update && apt-get install -y libpcre3-dev zlib1g-dev inside ghcr.io/wolfssl/wolfprovider-test-deps:bookworm. Both packages already ship in that image: docker/wolfprovider-test-deps/Dockerfile:43 (zlib1g zlib1g-dev) and :58 (libpcre2-dev libpcre3-dev). That file states the policy explicitly at lines 4 and 13-14: "Goal: zero apt-get update calls during PR CI" and "Anything you add here ships in the image; do not add per-workflow installs." The step adds an avoidable apt round-trip and a network-flake failure mode to every matrix row. The sibling libacvp-pqc.yml added in this same PR correctly installs nothing.

Suggestion: Delete the step entirely.

Recommendation: Drop the step; both packages are already present. If something is genuinely missing, add it to docker/wolfprovider-test-deps/Dockerfile per that file's policy.

Comment thread src/wp_slhdsa_kmgmt.c
}
if (ok) {
privLen = privAllocLen = slhdsa->data->privKeySize;
priv = OPENSSL_malloc(privAllocLen);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] Missing cast on OPENSSL_malloc return
🔧 NIT convention

Every other allocation in this file casts the OPENSSL_malloc return (lines 457, 482, 646, 647, 683, 684, 1023, 1042, 1943, 2006), as does ML-DSA throughout. Line 766 is the only one that does not.

Suggestion:

Suggested change
priv = OPENSSL_malloc(privAllocLen);
priv = (unsigned char*)OPENSSL_malloc(privAllocLen);

Recommendation: Add the cast for consistency with the rest of the file.

Comment thread src/wp_slhdsa_kmgmt.c
ok = 0;
}

if (dupPub) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] if (dupPub) is not guarded by ok while the adjacent if (ok && dupPriv) is
🔧 NIT style

When wp_slhdsa_new returns NULL, ok is set to 0 but control still enters the if (dupPub) block and allocates pubBuf. No crash results -- the two inner if (ok) guards prevent &dst->key from ever being evaluated, and OPENSSL_free(pubBuf) cleans up -- but the block reads as though dst were valid, and the asymmetry with the if (ok && dupPriv) block directly below invites a future edit that dereferences dst unguarded.

Suggestion:

Suggested change
if (dupPub) {
if (ok && dupPub) {

Recommendation: Add the ok && guard so both branches read identically and no allocation happens on the already-failed path.

Comment thread src/wp_slhdsa_sig.c
int ok = 1;
int rc = -1;
int locked = 0;
word32 sigSz;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] word32 sigSz; declared uninitialized in wp_slhdsa_sign
🔧 NIT style

sigSz is assigned only inside if (ok) at line 374 and read only inside if (ok && ...) at lines 377, 384 and 400. The correlation makes every read safe in practice, but it is not one a compiler can prove, and this repo builds with -Wall -Wextra where GCC's -Wmaybe-uninitialized regularly fires on exactly this ok-flag pattern. Every other local in the function (ok, rc, locked, dummy) is initialised at declaration.

Suggestion:

Suggested change
word32 sigSz;
word32 sigSz = 0;

Recommendation: Initialise to 0 for consistency with the surrounding declarations and to keep the build warning-clean across compiler versions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:libacvp-pqc PR OSP toggle: run libacvp-pqc

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants